Spring Bean
contents
1. 스프링 빈(Spring Bean)이란?
일반적인 자바에서는 new를 사용해 객체를 만듭니다.
UserService service = new UserService(); // 개발자가 직접 관리함
스프링에서 빈(Bean) 은 스프링 IoC(제어의 역전) 컨테이너가 인스턴스화하고, 조립하고, 관리하는 자바 객체를 말합니다.
@Service
public class UserService { ... } // 스프링이 관리함
즉, 객체 생성의 제어권을 개발자(나)에게서 스프링으로 넘기는 것입니다.
2. 어디에 할당되나요? (메모리와 위치)
이 질문은 두 가지 관점에서 봐야 합니다.
A. 물리적 메모리 (JVM 힙 영역)
다른 자바 객체들과 마찬가지로, 스프링 빈도 자바 힙(Heap) 메모리 안에 존재합니다.
- 싱글톤(Singleton) 빈(기본값)이라면, 애플리케이션이 실행되는 동안 힙에 계속 남아있습니다.
- 프로토타입(Prototype) 빈이라면, 요청될 때 힙에 생성되고 사용이 끝나면 가비지 컬렉션(GC) 됩니다.
B. 논리적 위치 (컨테이너의 캐시)
스프링은 힙에 있는 이 객체들의 주소(참조)를 ApplicationContext 내부에 보관합니다. 특히 싱글톤 빈의 경우 내부적으로 Map에 저장됩니다.
// DefaultSingletonBeanRegistry.java (스프링 코어 깊은 곳에 있는 코드)
private final Map singletonObjects = new ConcurrentHashMap<>(256);
- Key: 빈의 이름 (예:
"userService") - Value: 힙에 있는 실제 객체의 인스턴스
여러분이 빈을 요청하면, 스프링은 이 Map을 조회해서 주소를 찾아 건네줍니다.
3. 어떻게 생성되나요? (라이프사이클 과정)
이 부분이 가장 중요합니다. 애플리케이션을 시작할 때(예: SpringApplication.run), 컨테이너는 빈을 만들기 위해 복잡한 과정을 거칩니다.
싱글톤 빈 하나가 생성되는 단계별 흐름은 다음과 같습니다.
1단계: 빈 정의 스캔 (Bean Definition Scanning)
먼저 스프링은 클래스들을 스캔합니다 (@Component, @Service, @Bean 등을 찾음). 그리고 BeanDefinition 객체를 만듭니다. 이것은 아직 빈이 아닙니다. 나중에 빈을 어떻게 만들지 적어둔 "설계도" 혹은 "레시피"입니다.
2단계: 인스턴스화 (Instantiation)
스프링은 자바 리플렉션(Reflection)을 사용해 껍데기 객체를 생성합니다.
- 생성자 호출:
new UserService() - 이 시점에는 메모리에 객체가 존재하지만, 내부 필드(예:
@Autowired된 리포지토리 등)는 모두null상태입니다.
3단계: 의존성 주입 (Dependency Injection)
스프링은 @Autowired가 붙은 필드나 세터(Setter)를 확인합니다.
- 컨테이너에서 필요한 의존성 빈을 찾습니다.
- 현재 빈 인스턴스에 그 의존성을 주입해 줍니다.
- 이제 빈에 데이터는 들어갔지만, 아직 완전히 "준비"된 건 아닙니다.
4단계: Aware 인터페이스 호출
만약 빈이 특별한 인터페이스(예: BeanNameAware, ApplicationContextAware)를 구현했다면, 스프링이 이를 호출해 줍니다.
- "이게 너의 ID야."
- "이게 컨테이너 주소야."
5단계: BeanPostProcessor (초기화 전 - Before Initialization)
이곳은 중간 가로채기 지점입니다. 스프링은 모든 BeanPostProcessor를 돌면서 postProcessBeforeInitialization을 호출합니다.
- 개발자들은 초기화 로직이 실행되기 전 에 빈을 조작하고 싶을 때 이 기능을 사용합니다.
6단계: 초기화 (Initialization)
이제 스프링은 초기화 로직을 실행합니다.
- 메서드에
@PostConstruct가 붙어 있다면 지금 실행됩니다. - 빈이
InitializingBean을 구현했다면afterPropertiesSet()이 실행됩니다.
7단계: BeanPostProcessor (초기화 후 - After Initialization) - 마법이 일어나는 곳
스프링은 postProcessAfterInitialization을 호출합니다.
- 매우 중요: 이곳이 바로 AOP(관점 지향 프로그래밍) 가 적용되는 시점입니다.
- 만약 빈에
@Transactional이나@Async가 붙어 있다면, 스프링은 원본 객체를 반환하지 않습니다. 대신 트랜잭션 로직을 처리하는 프록시(Proxy, 대리자) 객체로 원본을 감싼 뒤, 그 프록시 를 컨테이너에 등록합니다.
8단계: 사용 준비 완료
이제 빈이 singletonObjects Map에 저장됩니다. 다른 빈에 주입될 준비가 끝났습니다.
4. 어떻게 사용되나요?
빈이 컨테이너에 들어가면, 의존성 주입(DI) 을 통해 사용할 수 있습니다. 주입받는 방법은 크게 3가지입니다.
- 생성자 주입 (권장)
가장 좋은 방법입니다. 의존성 없이는 빈이 생성될 수 없도록 강제합니다.
@Service
public class OrderService {
private final UserService userService;
// 스프링이 이걸 보고, "UserService"를 Map에서 찾아와서 넣어줍니다.
public OrderService(UserService userService) {
this.userService = userService;
}
}
2. 필드 주입 (비권장)
@Autowired
private UserService userService; // 스프링이 리플렉션을 써서 private 필드에 억지로 넣습니다.
왜 피해야 할까요? 테스트가 어렵습니다(생성자가 없어서 Mock 객체를 넣을 수 없음). 의존성이 숨겨집니다.
- 세터(Setter) 주입
선택적인(Optional) 의존성일 때 사용합니다.
5. 빈 스코프 (존재의 규칙)
@Scope를 사용해 라이프사이클을 조정할 수 있습니다.
| 스코프 | 설명 |
|---|---|
| Singleton (기본값) | ApplicationContext 당 단 하나의 인스턴스만 존재합니다. 모든 요청이 똑같은 객체를 공유합니다. 주의: 상태값(예: user_id)을 필드에 저장하면 안 됩니다. 다른 사용자가 덮어씁니다! |
| Prototype | 요청할 때마다 새로운 인스턴스가 생성됩니다. |
| Request (웹) | HTTP 요청 하나당 하나의 빈이 생성되고, 응답이 나가면 사라집니다. |
| Session (웹) | HTTP 세션(사용자) 하나당 하나의 빈이 유지됩니다. |
6. 어떻게 소멸되나요?
애플리케이션이 종료될 때 (ctx.close()):
@PreDestroy: 이 어노테이션이 붙은 메서드를 호출합니다. DB 연결을 끊거나 파일 스트림을 닫는 등 리소스 해제를 여기서 합니다.- DisposableBean: 인터페이스를 구현했다면
destroy()가 호출됩니다. - 메모리 해제:
singletonObjectsMap에서 참조가 제거되고, 힙에 있던 객체는 결국 가비지 컬렉터(GC)가 수거해 갑니다.
주니어 개발자를 위한 요약 체크리스트
- 정의: 스프링이 관리하는 POJO(평범한 자바 객체)다.
- 저장소: 힙(Heap) 메모리에 살고,
ApplicationContext내부의Map에 주소가 적혀 있다. - 생성 과정: 스캔 -> 인스턴스화 -> 의존성 주입 ->
@PostConstruct-> 프록시 생성(필요시). - 사용: 생성자 주입을 통해 받아서 쓴다.
문제 상황: 순환 참조 (Circular Dependency)
두 개의 빈, Service A와 Service B가 있다고 가정해 봅시다.
- Service A는 Service B가 필요합니다.
- Service B는 Service A가 필요합니다.
@Service
public class ServiceA {
@Autowired
private ServiceB serviceB;
}
@Service
public class ServiceB {
@Autowired
private ServiceA serviceA;
}
논리적 교착 상태(Deadlock):
- 스프링이 A를 생성하려고 합니다.
- A가 말합니다. "B가 필요해요."
- 스프링은 A를 멈추고 B를 생성하러 갑니다.
- B가 말합니다. "A가 필요해요."
- 스프링은 A를 찾지만, A는 아직 다 만들어지지 않았습니다! -> 무한 루프 / 스택 오버플로우 발생.
해결책: 3개의 캐시 (The Three Caches)
스프링은 **인스턴스화(Instantiation, 메모리에 껍데기 객체 생성)**와 초기화(Initialization, 의존성 주입) 단계를 분리함으로써 이 문제를 해결합니다.
스프링은 내부적으로 DefaultSingletonBeanRegistry라는 곳에 위치한 3개의 Map(캐시)을 사용합니다.
| 레벨 | 캐시 이름 | 저장되는 것 |
|---|---|---|
| 1단계 | singletonObjects |
완전히 초기화된 빈. 당장 사용할 수 있는 상태입니다. 스프링은 여기를 가장 먼저 확인합니다. |
| 2단계 | earlySingletonObjects |
완성되지 않은 빈. 인스턴스화(메모리 할당)는 되었지만, 의존성 주입은 아직 안 된 상태입니다. 오직 순환 참조를 해결하기 위해서만 사용됩니다. |
| 3단계 | singletonFactories |
빈(혹은 빈의 프록시)을 조기에 생산할 수 있는 **팩토리(람다 식)**가 저장됩니다. |
단계별 실행 흐름 (Step-by-Step)
여러분이 ServiceA를 요청했을 때 어떤 일이 벌어지는지 추적해 보겠습니다.
1단계: 'A' 생성 시작
- 스프링이 1단계 캐시를 확인합니다.
ServiceA는 없습니다. - 스프링이
ServiceA를 인스턴스화합니다 (new ServiceA()호출).- 이제 힙 메모리에 "빈 껍데기" 객체가 생겼습니다. 데이터는 아직 없습니다.
- 결정적 단계: 스프링은 A를 만들기 위한 팩토리를 3단계 캐시(
singletonFactories)에 넣습니다.- 마치 이렇게 말하는 것과 같습니다: "내가 다 만들기 전에 누가 급하게 A를 찾으면, 이 팩토리를 돌려서 A의 주소(참조)를 가져가세요."
- A에게 의존성을 주입하려고 보니
ServiceB가 필요함을 알게 됩니다.
2단계: 'B' 생성 시작 (A가 필요로 하므로)
- 스프링은 A 생성을 잠시 멈추고
ServiceB생성을 시작합니다. - 1단계 캐시를 확인합니다. B는 없습니다.
ServiceB를 인스턴스화합니다.- B를 위한 팩토리를 3단계 캐시에 넣습니다.
- B에게 의존성을 주입하려고 보니
ServiceA가 필요함을 알게 됩니다.
3단계: B의 A에 대한 의존성 해결
- B가
ServiceA를 요청합니다. - 1단계 확인: 없음 (A는 아직 미완성).
- 2단계 확인: 없음.
- 3단계 확인: 찾았다! A를 위한 팩토리가 있습니다 (1-3단계에서 넣어둠).
- 동작:
- 스프링이 팩토리를 실행합니다. 팩토리는 "빈 껍데기" A(혹은 A의 프록시)의 주소를 반환합니다.
- A를 3단계에서 -> 2단계(
earlySingletonObjects)로 이동시킵니다. - A를 3단계에서 제거합니다.
- 주입: 미완성 상태인 A가 B에 주입됩니다.
- 참고: B는 이제 A를 가리키는 참조를 갖게 되었습니다. A가 비어있더라도 메모리 주소는 유효합니다.
4단계: 'B' 완성
- B는 모든 의존성을 갖췄습니다 (A의 참조를 가짐).
- B의 초기화가 완료됩니다 (
@PostConstruct등 실행). - B가 3단계에서 -> 1단계(
singletonObjects)로 이동합니다. - B는 이제 완전히 준비되었습니다.
5단계: 'A' 완성
- 스프링은 멈춰뒀던 A로 돌아옵니다 (1-4단계 시점).
- A는 방금 완성된
ServiceB를 주입받습니다. - A의 초기화가 완료됩니다.
- A가 2단계에서 -> 1단계로 이동합니다.
결과: 두 빈 모두 서로를 참조하고 있으며, 스택 오버플로우 없이 정상적으로 생성되었습니다.
심화 질문: 왜 2단계가 아니라 3단계인가요?
"그냥 껍데기 객체를 바로 2단계에 넣으면 되지, 굳이 왜 팩토리(3단계)가 필요한가요?" 라는 의문이 들 수 있습니다.
정답은 AOP(관점 지향 프로그래밍)와 프록시(Proxy) 때문입니다.
만약 ServiceA에 @Transactional이 붙어 있다면, 스프링은 "원본 A"가 아니라 A를 감싼 프록시 A를 컨테이너에 넣어야 합니다.
만약 2단계만 쓴다면 생기는 문제:
보통 스프링은 객체 생성의 맨 마지막 단계(라이프사이클의 8단계)에서 프록시를 만듭니다.
만약 원본 객체를 미리 2단계에 넣어버리면:
ServiceB는 원본 A를 가져갑니다.- 나중에 컨테이너(1단계)에는 프록시 A가 들어갑니다.
- 재앙: 시스템 안에 A를 나타내는 객체가 두 개가 됩니다.
ServiceB가 가진 A는 트랜잭션이 동작하지 않는 원본 객체가 되어버립니다!
해결책 (3단계 팩토리):
3단계에 있는 팩토리는 다음과 같은 로직을 갖고 있습니다: "내가 얘를 프록시로 감싸야 하나?"
- 순환 참조가 발생하면, 팩토리가 조기에(Early) 실행됩니다.
- 이때 팩토리가 즉시 프록시를 생성해서
ServiceB에게 줍니다. - 따라서
ServiceB도 원본이 아닌 프록시를 안전하게 가져가게 됩니다.
캐시 요약
- 3단계 (
singletonFactories): "누가 급하게 찾을 때를 대비해서, 참조(필요하면 프록시)를 만들어주는 람다식을 여기 보관함." - 2단계 (
earlySingletonObjects): "누가 급하게 찾아서 내가 이미 만들었어. 팩토리를 두 번 돌리지 않게 여기다 만들어둔 참조(원본 혹은 프록시)를 캐싱함." - 1단계 (
singletonObjects): "나는 완전히 생성되고 주입도 끝난 완성품임."
이 방식이 실패하는 경우?
이 메커니즘은 Setter 주입이나 Field 주입에서만 동작합니다.
생성자 주입(Constructor Injection) 에서는 실패합니다.
public ServiceA(ServiceB b) { ... } // 실패!
이유:
"빈 껍데기" 객체를 3단계에 넣으려면 일단 객체를 생성(인스턴스화)해야 합니다. 하지만 생성자를 호출하려면 ServiceB가 필요하므로, 객체조차 만들 수 없는 "닭이 먼저냐 달걀이 먼저냐" 상황이 됩니다.
해결 방법: 생성자 파라미터에 @Lazy를 사용하세요.
public ServiceA(@Lazy ServiceB b) { ... }
이렇게 하면 임시 프록시를 먼저 넣어줘서 생성자가 통과되게 만듭니다.
references